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] 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 "". +`PAPERLESS_TRUSTED_PROXIES=` + +: 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=` : 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}.`", + )