From 668b068bb577b08374ac4b5c563cc11412243586 Mon Sep 17 00:00:00 2001
From: Michael Shamoon <4887959+shamoon@users.noreply.github.com>
Date: Sat, 31 Dec 2022 13:13:19 -0800
Subject: [PATCH 1/3] Log failed login attempts

---
 Pipfile                   |  1 +
 Pipfile.lock              | 10 +++++++++-
 docs/configuration.md     |  8 ++++++++
 src/paperless/apps.py     | 15 +++++++++++++++
 src/paperless/settings.py |  7 +++++++
 src/paperless/signals.py  | 32 ++++++++++++++++++++++++++++++++
 6 files changed, 72 insertions(+), 1 deletion(-)
 create mode 100644 src/paperless/apps.py
 create mode 100644 src/paperless/signals.py

diff --git a/Pipfile b/Pipfile
index 2684ef2da..0909f12b2 100644
--- a/Pipfile
+++ b/Pipfile
@@ -67,6 +67,7 @@ djangorestframework-guardian = "*"
 # Locked version until https://github.com/django/channels_redis/issues/332
 # is resolved
 channels-redis = "==3.4.1"
+django-ipware = "*"
 
 
 [dev-packages]
diff --git a/Pipfile.lock b/Pipfile.lock
index efe0eabbb..14237a002 100644
--- a/Pipfile.lock
+++ b/Pipfile.lock
@@ -1,7 +1,7 @@
 {
     "_meta": {
         "hash": {
-            "sha256": "99f415c5ce96020dc3fcb137dc15d47cc5431686bdce1ca42e6254a2719060a8"
+            "sha256": "0e1a26c5e9acb1d745f951f92d00d60272f83406467d90551e558972697b53cd"
         },
         "pipfile-spec": 6,
         "requires": {},
@@ -480,6 +480,14 @@
             "index": "pypi",
             "version": "==2.4.0"
         },
+        "django-ipware": {
+            "hashes": [
+                "sha256:602a58325a4808bd19197fef2676a0b2da2df40d0ecf21be414b2ff48c72ad05",
+                "sha256:878dbb06a87e25550798e9ef3204ed70a200dd8b15e47dcef848cf08244f04c9"
+            ],
+            "index": "pypi",
+            "version": "==4.0.2"
+        },
         "djangorestframework": {
             "hashes": [
                 "sha256:579a333e6256b09489cbe0a067e66abe55c6595d8926be6b99423786334350c8",
diff --git a/docs/configuration.md b/docs/configuration.md
index 5d4ed7a69..6c233c2e6 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -262,6 +262,14 @@ do CORS calls. Set this to your public domain name.
 
     Defaults to "<http://localhost:8000>".
 
+`PAPERLESS_TRUSTED_PROXIES=<comma-separated-list>`
+
+: This may be needed to prevent IP address spoofing if you are using e.g.
+fail2ban with log entries for failed authorization attempts. Value should be
+IP address(es).
+
+    Defaults to empty string.
+
 `PAPERLESS_FORCE_SCRIPT_NAME=<path>`
 
 : To host paperless under a subpath url like example.com/paperless you
diff --git a/src/paperless/apps.py b/src/paperless/apps.py
new file mode 100644
index 000000000..323099745
--- /dev/null
+++ b/src/paperless/apps.py
@@ -0,0 +1,15 @@
+from django.apps import AppConfig
+from django.utils.translation import gettext_lazy as _
+from paperless.signals import handle_failed_login
+
+
+class PaperlessConfig(AppConfig):
+    name = "paperless"
+
+    verbose_name = _("Paperless")
+
+    def ready(self):
+        from django.contrib.auth.signals import user_login_failed
+
+        user_login_failed.connect(handle_failed_login)
+        AppConfig.ready(self)
diff --git a/src/paperless/settings.py b/src/paperless/settings.py
index 7fc384eaa..41f08f3e2 100644
--- a/src/paperless/settings.py
+++ b/src/paperless/settings.py
@@ -416,6 +416,13 @@ if _paperless_url:
         # always allow localhost. Necessary e.g. for healthcheck in docker.
         ALLOWED_HOSTS = [_paperless_uri.hostname] + ["localhost"]
 
+# For use with trusted proxies
+_trusted_proxies = os.getenv("PAPERLESS_TRUSTED_PROXIES")
+if _trusted_proxies:
+    TRUSTED_PROXIES = _trusted_proxies.split(",")
+else:
+    TRUSTED_PROXIES = []
+
 # The secret key has a default that should be fine so long as you're hosting
 # Paperless on a closed network.  However, if you're putting this anywhere
 # public, you should change the key to something unique and verbose.
diff --git a/src/paperless/signals.py b/src/paperless/signals.py
new file mode 100644
index 000000000..cedad7f67
--- /dev/null
+++ b/src/paperless/signals.py
@@ -0,0 +1,32 @@
+import logging
+
+from django.conf import settings
+from ipware import get_client_ip
+
+logger = logging.getLogger("paperless.auth")
+
+
+# https://docs.djangoproject.com/en/4.1/ref/contrib/auth/#django.contrib.auth.signals.user_login_failed
+def handle_failed_login(sender, credentials, request, **kwargs):
+    client_ip, is_routable = get_client_ip(
+        request,
+        proxy_trusted_ips=settings.TRUSTED_PROXIES,
+    )
+    if client_ip is None:
+        logger.info(
+            f"Login failed for user `{credentials['username']}`."
+            + " Unable to determine IP address.",
+        )
+    else:
+        if is_routable:
+            # We got the client's IP address
+            logger.info(
+                f"Login failed for user `{credentials['username']}`"
+                + f" from IP `{client_ip}.`",
+            )
+        else:
+            # The client's IP address is private
+            logger.info(
+                f"Login failed for user `{credentials['username']}`"
+                + f" from private IP `{client_ip}.`",
+            )

From 72f58d54a36ec11eeb7f1f73ade8a82842dd8cd1 Mon Sep 17 00:00:00 2001
From: Trenton H <797416+stumpylog@users.noreply.github.com>
Date: Wed, 11 Jan 2023 07:55:08 -0800
Subject: [PATCH 2/3] Moves django-ipware up to be with other Django libraries

---
 Pipfile | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/Pipfile b/Pipfile
index 0909f12b2..b425fe2c1 100644
--- a/Pipfile
+++ b/Pipfile
@@ -16,6 +16,7 @@ django-compression-middleware = "*"
 django-extensions = "*"
 django-filter = "~=22.1"
 djangorestframework = "~=3.14"
+django-ipware = "*"
 filelock = "*"
 gunicorn = "*"
 imap-tools = "*"
@@ -67,7 +68,7 @@ djangorestframework-guardian = "*"
 # Locked version until https://github.com/django/channels_redis/issues/332
 # is resolved
 channels-redis = "==3.4.1"
-django-ipware = "*"
+
 
 
 [dev-packages]

From 07ec6ff7ab048466cd87640f7b1d6b1d8b55f018 Mon Sep 17 00:00:00 2001
From: Trenton H <797416+stumpylog@users.noreply.github.com>
Date: Sat, 18 Feb 2023 15:26:09 -0800
Subject: [PATCH 3/3] Adds some quick testing of the IP logging during a failed
 login

---
 src/paperless/tests/test_signals.py | 80 +++++++++++++++++++++++++++++
 1 file changed, 80 insertions(+)
 create mode 100644 src/paperless/tests/test_signals.py

diff --git a/src/paperless/tests/test_signals.py b/src/paperless/tests/test_signals.py
new file mode 100644
index 000000000..1a4d7892f
--- /dev/null
+++ b/src/paperless/tests/test_signals.py
@@ -0,0 +1,80 @@
+from django.http import HttpRequest
+from django.test import TestCase
+from paperless.signals import handle_failed_login
+
+
+class TestFailedLoginLogging(TestCase):
+    def setUp(self):
+        super().setUp()
+
+        self.creds = {
+            "username": "john lennon",
+        }
+
+    def test_none(self):
+        """
+        GIVEN:
+            - Request with no IP possible
+        WHEN:
+            - Request provided to signal handler
+        THEN:
+            - Unable to determine logged
+        """
+        request = HttpRequest()
+        request.META = {}
+        with self.assertLogs("paperless.auth") as logs:
+            handle_failed_login(None, self.creds, request)
+
+            self.assertEqual(
+                logs.output,
+                [
+                    "INFO:paperless.auth:Login failed for user `john lennon`. Unable to determine IP address.",
+                ],
+            )
+
+    def test_public(self):
+        """
+        GIVEN:
+            - Request with publicly routeable IP
+        WHEN:
+            - Request provided to signal handler
+        THEN:
+            - Expected IP is logged
+        """
+        request = HttpRequest()
+        request.META = {
+            "HTTP_X_FORWARDED_FOR": "177.139.233.139",
+        }
+        with self.assertLogs("paperless.auth") as logs:
+            handle_failed_login(None, self.creds, request)
+
+            self.assertEqual(
+                logs.output,
+                [
+                    "INFO:paperless.auth:Login failed for user `john lennon` from IP `177.139.233.139.`",
+                ],
+            )
+
+    def test_private(self):
+        """
+        GIVEN:
+            - Request with private range IP
+        WHEN:
+            - Request provided to signal handler
+        THEN:
+            - Expected IP is logged
+            - IP is noted to be a private IP
+        """
+        request = HttpRequest()
+        request.META = {
+            "HTTP_X_FORWARDED_FOR": "10.0.0.1",
+        }
+        with self.assertLogs("paperless.auth") as logs:
+            handle_failed_login(None, self.creds, request)
+
+            self.assertEqual(
+                logs.output,
+                [
+                    "INFO:paperless.auth:Login failed for user `john lennon` from private IP `10.0.0.1.`",
+                ],
+            )