From 54a95a0ee2a87daf85b95402e3781b362ad4fcd5 Mon Sep 17 00:00:00 2001 From: Shea Polansky Date: Wed, 9 Dec 2020 01:25:56 -0800 Subject: [PATCH] Add http header auth (#457) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add HTTP header authentiation backend/middleware * Add docs for remote header auth * Improve docs on external auth * Add warning for unknown REMOTE_USER_HEADER_TYPE * Move active check for header auth to middleware Add extra header type sanity check to the backend * Add test cases for remote header login * Improve header-based authentication - remove the 'ID' mode - add CustomHeaderBackend to AUTHENTICATION_BACKENDS conditionally - rewrite CustomHeaderBackend and CustomHeaderMiddleware to use less inherited code - add more test cases Co-authored-by: PÄ“teris Caune --- README.md | 22 ++++++++ hc/accounts/backends.py | 30 ++++++++++ hc/accounts/middleware.py | 50 +++++++++++++++++ .../tests/test_remote_user_header_login.py | 56 +++++++++++++++++++ hc/settings.py | 6 ++ 5 files changed, 164 insertions(+) create mode 100644 hc/accounts/tests/test_remote_user_header_login.py diff --git a/README.md b/README.md index c18fbc72..4752e091 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,7 @@ Healthchecks reads configuration from the following environment variables: | PUSHOVER_EMERGENCY_EXPIRATION | `86400` | PUSHOVER_EMERGENCY_RETRY_DELAY | `300` | PUSHOVER_SUBSCRIPTION_URL | `None` +| REMOTE_USER_HEADER | `None` | See [External Authentication](#external-authentication) for details. | SHELL_ENABLED | `"False"` | SLACK_CLIENT_ID | `None` | SLACK_CLIENT_SECRET | `None` @@ -328,6 +329,27 @@ Note that WebAuthn requires HTTPS, even if running on localhost. To test WebAuth locally with a self-signed certificate, you can use the `runsslserver` command from the `django-sslserver` package. +## External Authentication + +HealthChecks supports external authentication by means of HTTP headers set by +reverse proxies or the WSGI server. This allows you to integrate it into your +existing authentication system (e.g., LDAP or OAuth) via an authenticating proxy. +When this option is enabled, **healtchecks will trust the header's value implicitly**, +so it is **very important** to ensure that attackers cannot set the value themselves +(and thus impersonate any user). How to do this varies by your chosen proxy, +but generally involves configuring it to strip out headers that normalize to the +same name as the chosen identity header. + +To enable this feature, set the `REMOTE_USER_HEADER` value to a header you wish to +authenticate with. HTTP headers will be prefixed with `HTTP_` and have any dashes +converted to underscores. Headers without that prefix can be set by the WSGI server +itself only, which is more secure. + +When `REMOTE_USER_HEADER` is set, Healthchecks will: + - assume the header contains user's email address + - look up and automatically log in the user with a matching email address + - automatically create an user account if it does not exist + - disable the default authentication methods (login link to email, password) ## Integrations diff --git a/hc/accounts/backends.py b/hc/accounts/backends.py index a279b56a..08d150e6 100644 --- a/hc/accounts/backends.py +++ b/hc/accounts/backends.py @@ -1,5 +1,7 @@ from django.contrib.auth.models import User +from django.conf import settings from hc.accounts.models import Profile +from hc.accounts.views import _make_user class BasicBackend(object): @@ -36,3 +38,31 @@ class EmailBackend(BasicBackend): if user.check_password(password): return user + + +class CustomHeaderBackend(BasicBackend): + """ + This backend works in conjunction with the ``CustomHeaderMiddleware``, + and is used when the server is handling authentication outside of Django. + + """ + + def authenticate(self, request, remote_user_email): + """ + The email address passed as remote_user_email is considered trusted. + Return the User object with the given email address. Create a new User + if it does not exist. + + """ + + # This backend should only be used when header-based authentication is enabled + assert settings.REMOTE_USER_HEADER + # remote_user_email should have a value + assert remote_user_email + + try: + user = User.objects.get(email=remote_user_email) + except User.DoesNotExist: + user = _make_user(remote_user_email) + + return user diff --git a/hc/accounts/middleware.py b/hc/accounts/middleware.py index 353d492f..99173153 100644 --- a/hc/accounts/middleware.py +++ b/hc/accounts/middleware.py @@ -1,3 +1,7 @@ +from django.contrib import auth +from django.contrib.auth.middleware import RemoteUserMiddleware +from django.conf import settings + from hc.accounts.models import Profile @@ -11,3 +15,49 @@ class TeamAccessMiddleware(object): request.profile = Profile.objects.for_user(request.user) return self.get_response(request) + + +class CustomHeaderMiddleware(RemoteUserMiddleware): + """ + Middleware for utilizing Web-server-provided authentication. + + If request.user is not authenticated, then this middleware: + - looks for an email address in request.META[settings.REMOTE_USER_HEADER] + - looks up and automatically logs in the user with a matching email + + """ + + def process_request(self, request): + if not settings.REMOTE_USER_HEADER: + return + + # Make sure AuthenticationMiddleware is installed + assert hasattr(request, "user") + + email = request.META.get(settings.REMOTE_USER_HEADER) + if not email: + # If specified header doesn't exist or is empty then log out any + # authenticated user and return + if request.user.is_authenticated: + auth.logout(request) + return + + # If the user is already authenticated and that user is the user we are + # getting passed in the headers, then the correct user is already + # persisted in the session and we don't need to continue. + if request.user.is_authenticated: + if request.user.email == email: + return + else: + # An authenticated user is associated with the request, but + # it does not match the authorized user in the header. + auth.logout(request) + + # We are seeing this user for the first time in this session, attempt + # to authenticate the user. + user = auth.authenticate(request, remote_user_email=email) + if user: + # User is valid. Set request.user and persist user in the session + # by logging the user in. + request.user = user + auth.login(request, user) diff --git a/hc/accounts/tests/test_remote_user_header_login.py b/hc/accounts/tests/test_remote_user_header_login.py new file mode 100644 index 00000000..4850584b --- /dev/null +++ b/hc/accounts/tests/test_remote_user_header_login.py @@ -0,0 +1,56 @@ +from unittest.mock import patch + +from django.contrib.auth.models import User +from django.test.utils import override_settings +from hc.test import BaseTestCase + + +@override_settings( + REMOTE_USER_HEADER="AUTH_USER", + AUTHENTICATION_BACKENDS=("hc.accounts.backends.CustomHeaderBackend",), +) +class RemoteUserHeaderTestCase(BaseTestCase): + @override_settings(REMOTE_USER_HEADER=None) + def test_it_does_nothing_when_not_configured(self): + r = self.client.get("/accounts/profile/", AUTH_USER="alice@example.org") + self.assertRedirects(r, "/accounts/login/?next=/accounts/profile/") + + def test_it_logs_user_in(self): + r = self.client.get("/accounts/profile/", AUTH_USER="alice@example.org") + self.assertContains(r, "alice@example.org") + + def test_it_does_nothing_when_header_not_set(self): + r = self.client.get("/accounts/profile/") + self.assertRedirects(r, "/accounts/login/?next=/accounts/profile/") + + def test_it_does_nothing_when_header_is_empty_string(self): + r = self.client.get("/accounts/profile/", AUTH_USER="") + self.assertRedirects(r, "/accounts/login/?next=/accounts/profile/") + + def test_it_creates_user(self): + r = self.client.get("/accounts/profile/", AUTH_USER="dave@example.org") + self.assertContains(r, "dave@example.org") + + q = User.objects.filter(email="dave@example.org") + self.assertTrue(q.exists()) + + def test_it_logs_out_another_user_when_header_is_empty_string(self): + self.client.login(remote_user_email="bob@example.org") + + r = self.client.get("/accounts/profile/", AUTH_USER="") + self.assertRedirects(r, "/accounts/login/?next=/accounts/profile/") + + def test_it_logs_out_another_user(self): + self.client.login(remote_user_email="bob@example.org") + + r = self.client.get("/accounts/profile/", AUTH_USER="alice@example.org") + self.assertContains(r, "alice@example.org") + + def test_it_handles_already_logged_in_user(self): + self.client.login(remote_user_email="alice@example.org") + + with patch("hc.accounts.middleware.auth") as mock_auth: + r = self.client.get("/accounts/profile/", AUTH_USER="alice@example.org") + + self.assertFalse(mock_auth.authenticate.called) + self.assertContains(r, "alice@example.org") diff --git a/hc/settings.py b/hc/settings.py index 2ca152c1..ab40ea9d 100644 --- a/hc/settings.py +++ b/hc/settings.py @@ -58,12 +58,14 @@ INSTALLED_APPS = ( "hc.payments", ) + MIDDLEWARE = ( "django.middleware.security.SecurityMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", + "hc.accounts.middleware.CustomHeaderMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", "django.middleware.locale.LocaleMiddleware", @@ -75,6 +77,10 @@ AUTHENTICATION_BACKENDS = ( "hc.accounts.backends.ProfileBackend", ) +REMOTE_USER_HEADER = os.getenv("REMOTE_USER_HEADER") +if REMOTE_USER_HEADER: + AUTHENTICATION_BACKENDS = ("hc.accounts.backends.CustomHeaderBackend",) + ROOT_URLCONF = "hc.urls" TEMPLATES = [