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.`", + ], + )